import { MutableRefObject } from 'react'
import { Align, FixedSizeList, ListOnItemsRenderedProps } from 'react-window'
import { Store } from 'redux'

import { DefaultContainer } from '../components/default-container'
import { DefaultCursor } from '../components/default-cursor'
import { DefaultDragPreview } from '../components/default-drag-preview'
import { DefaultNode } from '../components/default-node'
import { DefaultRow } from '../components/default-row'
import { createIndex } from '../data/create-index'
import { createList } from '../data/create-list'
import { createRoot, ROOT_ID } from '../data/create-root'
import { Cursor } from '../dnd/compute-drop'
import { actions as dnd } from '../state/dnd-slice'
import { edit } from '../state/edit-slice'
import { focus, treeBlur } from '../state/focus-slice'
import { actions as visibility } from '../state/open-slice'
import { Actions, RootState } from '../state/root-reducer'
import { actions as selection } from '../state/selection-slice'
import { EditResult } from '../types/handlers'
import { TreeProps } from '../types/tree-props'
import { Identity, IdObj } from '../types/utils'
import * as utils from '../utils'
import { NodeApi } from './node-api'

const { safeRun, identify, identifyNull } = utils
export class TreeApi<T> {
  static editPromise: null | ((args: EditResult) => void)
  root: NodeApi<T>
  visibleNodes: NodeApi<T>[]
  visibleStartIndex: number = 0
  visibleStopIndex: number = 0
  idToIndex: { [id: string]: number }

  constructor(
    public store: Store<RootState, Actions>,
    public props: TreeProps<T>,
    public list: MutableRefObject<FixedSizeList | null>,
    public listEl: MutableRefObject<HTMLDivElement | null>
  ) {
    /* Changes here must also be made in update() */
    this.root = createRoot<T>(this)
    this.visibleNodes = createList<T>(this)
    this.idToIndex = createIndex(this.visibleNodes)
  }

  /* Changes here must also be made in constructor() */
  update(props: TreeProps<T>) {
    this.props = props
    this.root = createRoot<T>(this)
    this.visibleNodes = createList<T>(this)
    this.idToIndex = createIndex(this.visibleNodes)
  }

  /* Store helpers */

  dispatch(action: Actions) {
    return this.store.dispatch(action)
  }

  get state() {
    return this.store.getState()
  }

  get openState() {
    return this.state.nodes.open.unfiltered
  }

  /* Tree Props */

  get width() {
    return this.props.width ?? '100%'
  }

  get closedFolderIds() {
    return this.props.closedFolderItemIds ?? []
  }

  get height() {
    return this.props.height ?? 1500
  }

  get indent() {
    return this.props.indent ?? 24
  }

  get rowHeight() {
    return this.props.rowHeight ?? 26
  }

  get overscanCount() {
    return this.props.overscanCount ?? 1
  }

  get searchTerm() {
    return (this.props.searchTerm || '').trim()
  }

  get matchFn() {
    const match =
      this.props.searchMatch ??
      ((node, term) => {
        const string = JSON.stringify(
          Object.values(node.data as { [k: string]: unknown })
        )
        return string.toLocaleLowerCase().includes(term.toLocaleLowerCase())
      })
    return (node: NodeApi<T>) => match(node, this.searchTerm)
  }

  accessChildren(data: T) {
    const get = this.props.childrenAccessor || 'children'
    return utils.access<T[] | undefined>(data, get) ?? null
  }

  accessId(data: T) {
    const get = this.props.idAccessor || 'id'
    const id = utils.access<string>(data, get)
    if (!id)
      throw new Error(
        "Data must contain an 'id' property or props.idAccessor must return a string"
      )
    return id
  }

  /* Node Access */

  get firstNode() {
    return this.visibleNodes[0] ?? null
  }

  get lastNode() {
    return this.visibleNodes[this.visibleNodes.length - 1] ?? null
  }

  get focusedNode() {
    return this.get(this.state.nodes.focus.id) ?? null
  }

  get mostRecentNode() {
    return this.get(this.state.nodes.selection.mostRecent) ?? null
  }

  get nextNode() {
    const index = this.indexOf(this.focusedNode)
    if (index === null) return null
    else return this.at(index + 1)
  }

  get prevNode() {
    const index = this.indexOf(this.focusedNode)
    if (index === null) return null
    else return this.at(index - 1)
  }

  get(id: string | null): NodeApi<T> | null {
    if (!id) return null
    if (id in this.idToIndex) return this.visibleNodes[this.idToIndex[id]] || null
    else return null
  }

  at(index: number): NodeApi<T> | null {
    return this.visibleNodes[index] || null
  }

  nodesBetween(startId: string | null, endId: string | null) {
    if (startId === null || endId === null) return []
    const index1 = this.indexOf(startId) ?? 0
    const index2 = this.indexOf(endId)
    if (index2 === null) return []
    const start = Math.min(index1, index2)
    const end = Math.max(index1, index2)
    return this.visibleNodes.slice(start, end + 1)
  }

  indexOf(id: string | null | IdObj) {
    const key = utils.identifyNull(id)
    if (!key) return null
    return this.idToIndex[key]
  }

  /* Data Operations */

  get editingId() {
    return this.state.nodes.edit.id
  }

  createInternal() {
    return this.create({ type: 'internal' })
  }

  createLeaf() {
    return this.create({ type: 'leaf' })
  }

  async create(
    opts: {
      type?: 'internal' | 'leaf'
      parentId?: null | string
      index?: null | number
    } = {}
  ) {
    const parentId =
      opts.parentId === undefined ? utils.getInsertParentId(this) : opts.parentId
    const index = opts.index ?? utils.getInsertIndex(this)
    const type = opts.type ?? 'leaf'
    const data = await safeRun(this.props.onCreate, {
      type,
      parentId,
      index,
      parentNode: this.get(parentId),
    })
    if (data) {
      this.focus(data)
      setTimeout(() => {
        this.edit(data).then(() => {
          this.select(data)
          this.activate(data)
        })
      })
    }
  }

  async delete(node: string | IdObj | null | string[] | IdObj[]) {
    if (!node) return
    const idents = Array.isArray(node) ? node : [node]
    const ids = idents.map(identify)
    const nodes = ids.map(id => this.get(id)!).filter(n => !!n)
    await safeRun(this.props.onDelete, { nodes, ids })
  }

  edit(node: string | IdObj): Promise<EditResult> {
    const id = identify(node)
    this.resolveEdit({ cancelled: true })
    this.scrollTo(id)
    this.dispatch(edit(id))
    return new Promise(resolve => {
      TreeApi.editPromise = resolve
    })
  }

  async submit(identity: Identity, value: string) {
    if (!identity) return
    const id = identify(identity)
    await safeRun(this.props.onRename, {
      id,
      name: value,
      node: this.get(id)!,
    })
    setTimeout(() => {
      this.focus(this.get(id))
      this.dispatch(edit(null))
      this.resolveEdit({ cancelled: false, value })
      this.onFocus()
    }, 300)
  }

  reset() {
    this.dispatch(edit(null))
    this.resolveEdit({ cancelled: true })
    setTimeout(() => this.onFocus()) // Return focus to element;
  }

  activate(id: string | IdObj | null) {
    const node = this.get(identifyNull(id))
    if (!node) return
    safeRun(this.props.onActivate, node)
  }

  private resolveEdit(value: EditResult) {
    const resolve = TreeApi.editPromise
    if (resolve) resolve(value)
    TreeApi.editPromise = null
  }

  /* Focus and Selection */

  get selectedIds() {
    return this.state.nodes.selection.ids
  }

  get selectedNodes() {
    let nodes = []
    for (let id of Array.from(this.selectedIds)) {
      const node = this.get(id)
      if (node) nodes.push(node)
    }
    return nodes
  }

  focus(node: Identity, opts: { scroll?: boolean } = {}) {
    if (!node) return
    /* Focus is responsible for scrolling, while selection is
     * responsible for focus. If selectionFollowsFocus, then
     * just select it. */
    if (this.props.selectionFollowsFocus) {
      this.select(node)
    } else {
      this.dispatch(focus(identify(node)))
      if (opts.scroll !== false) this.scrollTo(node)
      if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode)
    }
  }

  pageUp() {
    const start = this.visibleStartIndex
    const stop = this.visibleStopIndex
    const page = stop - start
    let index = this.focusedNode?.rowIndex ?? 0
    if (index > start) {
      index = start
    } else {
      index = Math.max(start - page, 0)
    }
    this.focus(this.at(index))
  }

  pageDown() {
    const start = this.visibleStartIndex
    const stop = this.visibleStopIndex
    const page = stop - start
    let index = this.focusedNode?.rowIndex ?? 0
    if (index < stop) {
      index = stop
    } else {
      index = Math.min(index + page, this.visibleNodes.length - 1)
    }
    this.focus(this.at(index))
  }

  select(node: Identity, opts: { align?: Align; focus?: boolean } = {}) {
    if (!node) return
    const changeFocus = opts.focus !== false
    const id = identify(node)
    if (changeFocus) this.dispatch(focus(id))
    this.dispatch(selection.only(id))
    this.dispatch(selection.anchor(id))
    this.dispatch(selection.mostRecent(id))
    this.scrollTo(id, opts.align)
    if (this.focusedNode && changeFocus) {
      safeRun(this.props.onFocus, this.focusedNode)
    }
    safeRun(this.props.onSelect, this.selectedNodes)
  }

  deselect(node: Identity) {
    if (!node) return
    const id = identify(node)
    this.dispatch(selection.remove(id))
    safeRun(this.props.onSelect, this.selectedNodes)
  }

  selectMulti(identity: Identity) {
    const node = this.get(identifyNull(identity))
    if (!node) return
    this.dispatch(focus(node.id))
    this.dispatch(selection.add(node.id))
    this.dispatch(selection.anchor(node.id))
    this.dispatch(selection.mostRecent(node.id))
    this.scrollTo(node)
    if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode)
    safeRun(this.props.onSelect, this.selectedNodes)
  }

  selectMultiId(identity: Identity) {
    const node = this.get(identifyNull(identity))
    if (!node) return
    this.dispatch(selection.add(node.id))
  }

  selectContiguous(identity: Identity) {
    if (!identity) return
    const id = identify(identity)
    const { anchor, mostRecent } = this.state.nodes.selection
    this.dispatch(focus(id))
    this.dispatch(selection.remove(this.nodesBetween(anchor, mostRecent)))
    this.dispatch(selection.add(this.nodesBetween(anchor, identifyNull(id))))
    this.dispatch(selection.mostRecent(id))
    this.scrollTo(id)
    if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode)
    safeRun(this.props.onSelect, this.selectedNodes)
  }

  deselectAll() {
    this.setSelection({ ids: [], anchor: null, mostRecent: null })
    safeRun(this.props.onSelect, this.selectedNodes)
  }

  setSelectIds(ids: string[] | null) {
    this.setSelection({ ids, anchor: null, mostRecent: null })
  }

  selectAll() {
    this.setSelection({
      ids: Object.keys(this.idToIndex),
      anchor: this.firstNode,
      mostRecent: this.lastNode,
    })
    this.dispatch(focus(this.lastNode?.id))
    if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode)
    safeRun(this.props.onSelect, this.selectedNodes)
  }

  setSelection(args: {
    ids: (IdObj | string)[] | null
    anchor: IdObj | string | null
    mostRecent: IdObj | string | null
  }) {
    const ids = new Set(args.ids?.map(identify))
    const anchor = identifyNull(args.anchor)
    const mostRecent = identifyNull(args.mostRecent)
    this.dispatch(selection.set({ ids, anchor, mostRecent }))
    safeRun(this.props.onSelect, this.selectedNodes)
  }

  /* Drag and Drop */

  get cursorParentId() {
    const { cursor } = this.state.dnd
    switch (cursor.type) {
      case 'highlight':
        return cursor.id
      default:
        return null
    }
  }

  get cursorOverFolder() {
    return this.state.dnd.cursor.type === 'highlight'
  }

  get dragNodes() {
    return this.state.dnd.dragIds.map(id => this.get(id)).filter(n => !!n) as NodeApi<T>[]
  }

  canDrop() {
    // if (this.isFiltered) return false
    const parentNode = this.get(this.state.dnd.parentId) ?? this.root
    const dragNodes = this.dragNodes
    const isDisabled = this.props.disableDrop

    for (const drag of dragNodes) {
      if (!drag) return false
      if (!parentNode) return false
      if (drag.isInternal && utils.isDecendent(parentNode, drag)) return false
    }

    // Allow the user to insert their own logic
    if (typeof isDisabled == 'function') {
      return !isDisabled({
        parentNode,
        dragNodes: this.dragNodes,
        index: this.state.dnd.index,
      })
    } else if (typeof isDisabled == 'string') {
      // @ts-ignore
      return !parentNode.data[isDisabled]
    } else if (typeof isDisabled === 'boolean') {
      return !isDisabled
    } else {
      return true
    }
  }

  hideCursor() {
    this.dispatch(dnd.cursor({ type: 'none' }))
  }

  showCursor(cursor: Cursor) {
    this.dispatch(dnd.cursor(cursor))
  }

  /* Visibility */

  open(identity: Identity) {
    const id = identifyNull(identity)
    if (!id) return
    if (this.isOpen(id)) return
    this.dispatch(visibility.open(id, this.isFiltered))
    safeRun(this.props.onFolderOpen, id)
  }

  close(identity: Identity) {
    const id = identifyNull(identity)
    if (!id) return
    if (!this.isOpen(id)) return
    this.dispatch(visibility.close(id, this.isFiltered))
    safeRun(this.props.onFolderClose, id)
  }

  toggle(identity: Identity) {
    const id = identifyNull(identity)
    if (!id) return
    return this.isOpen(id) ? this.close(id) : this.open(id)
  }

  openParents(identity: Identity) {
    const id = identifyNull(identity)
    if (!id) return
    const node = utils.dfs(this.root, id)
    let parent = node?.parent

    while (parent) {
      this.open(parent.id)
      parent = parent.parent
    }
  }

  openSiblings(node: NodeApi<T>) {
    const parent = node.parent
    if (!parent) {
      this.toggle(node.id)
    } else if (parent.children) {
      const isOpen = node.isOpen
      for (let sibling of parent.children) {
        if (sibling.isInternal) {
          isOpen ? this.close(sibling.id) : this.open(sibling.id)
        }
      }
      this.scrollTo(this.focusedNode)
    }
  }

  openAll() {
    utils.walk(this.root, node => {
      if (node.isInternal) node.open()
    })
  }

  closeAll() {
    utils.walk(this.root, node => {
      if (node.isInternal) node.close()
    })
  }

  /* Scrolling */

  scrollTo(identity: Identity, align: Align = 'smart') {
    if (!identity) return
    const id = identify(identity)
    this.openParents(id)
    return utils
      .waitFor(() => id in this.idToIndex)
      .then(() => {
        const index = this.idToIndex[id]
        if (index === undefined) return
        this.list.current?.scrollToItem(index, align)
      })
      .catch(() => {
        // Id: ${id} never appeared in the list.
      })
  }

  /* State Checks */

  get isEditing() {
    return this.state.nodes.edit.id !== null
  }

  get isFiltered() {
    return !!this.props.searchTerm?.trim()
  }

  get hasFocus() {
    return this.state.nodes.focus.treeFocused
  }

  get hasNoSelection() {
    return this.state.nodes.selection.ids.size === 0
  }

  get hasOneSelection() {
    return this.state.nodes.selection.ids.size === 1
  }

  get hasMultipleSelections() {
    return this.state.nodes.selection.ids.size > 1
  }

  isSelected(id?: string) {
    if (!id) return false
    return this.state.nodes.selection.ids.has(id)
  }

  isOpen(id?: string) {
    if (!id) return false
    if (id === ROOT_ID) return true
    const def = this.props.openByDefault ?? true
    if (this.isFiltered) {
      return this.state.nodes.open.filtered[id] ?? true // Filtered folders are always opened by default
    } else {
      return this.state.nodes.open.unfiltered[id] ?? def
    }
  }

  isEditable(data: T) {
    const check = this.props.disableEdit || (() => false)
    return !utils.access(data, check) ?? true
  }

  isDraggable(data: T) {
    const check = this.props.disableDrag || (() => false)
    return !utils.access(data, check) ?? true
  }

  isDragging(node: string | IdObj | null) {
    const id = identifyNull(node)
    if (!id) return false
    return this.state.nodes.drag.id === id
  }

  isFocused(id: string) {
    return this.hasFocus && this.state.nodes.focus.id === id
  }

  isMatch(node: NodeApi<T>) {
    return this.matchFn(node)
  }

  willReceiveDrop(node: string | IdObj | null) {
    const id = identifyNull(node)
    if (!id) return false
    return id === this.state.nodes.drag.idWillReceiveDrop
  }

  onSearchClear() {
    this.props.onSearchClear?.()
  }

  /* Tree Event Handlers */

  onFocus() {
    const node = this.focusedNode || this.firstNode
    if (node) this.dispatch(focus(node.id))
  }

  onBlur() {
    this.dispatch(treeBlur())
  }

  onItemsRendered(args: ListOnItemsRenderedProps) {
    this.visibleStartIndex = args.visibleStartIndex
    this.visibleStopIndex = args.visibleStopIndex
  }

  /* Get Renderers */

  get renderContainer() {
    return this.props.renderContainer || DefaultContainer
  }

  get renderRow() {
    return this.props.renderRow || DefaultRow
  }

  get renderNode() {
    return this.props.children || DefaultNode
  }

  get renderDragPreview() {
    return this.props.renderDragPreview || DefaultDragPreview
  }

  get renderCursor() {
    return this.props.renderCursor || DefaultCursor
  }
}
